ECMAScript 标准中的异步

回调函数

这是函数作为一等公民或者一类对象带来的特性,函数可以像对象一样独立使用,可以作为参数和返回值等等。

常见的浏览器的DOM事件,NodeJS中的文件读写等等,都会在回调函数中有一个关键的第一个参数来保留现场和传递流程,比如Event对象,err对象等等。

Promise

通过返回Promise对象来实现链式调用,通过链式调用的方式来处理异步的结果,从一定程度上解决了回调地狱(异步回调嵌套太深)的问题,同时也提供了.all,.race等多种实用的方法来解决异步操作中的并行和并发的问题。

Generator

作为ESx的特性提供出来,一方面不是必须的(因为ES5照样能搞所有事,这么多年也是这么玩的),另一方面ES6也加入了更多底层或者元编程相关的内容。

生成器几个新的特性:

  • 控制代码内部执行
  • 控制代码内部返回
  • 控制代码内部抛异常

这让我们对代码执行有更多可控的支点,最常用的使用场景就是async/await这个语法糖的异步代码同步写法,一方面让代码逻辑清晰,另一方面也解决了不管是回调函数还是Promise也存在的回调地域问题。

async/await

从语法结构上来看是用了同步代码的表达方式支持了异步代码的表达,避免了回调层级深的问题,同时也让流程更加简化清晰。

浏览器中的异步

浏览器自身是没有标准的(全靠事实标准),但实现了很多其他的标准ECMAScript,HTML5,HTTP,DOM,XML等等了,还增加了一些非标准的但作为事实存在的标准,比如conosle

回调函数

  • 浏览器环境
1
2
function getData(callback){}
getData((data) => {}) // 传入的参数就是
  • Node环境
1
2
3
4
5
6
7
8
var fs = require('fs')
fs.readFile('test.txt',(err,data) => {
if(err){
console.log('readFile error:',err)
}else{
console.log('fileData:', data)
}
})

setTimeout/setInterval

这个是属于HTML规范的一部分,但为了兼容开发人员使用习惯,NodeJS环境中也是有实现的。

从规范中可以看到一些信息:

  • setTimeout/setInterval 函数可以有两种重载形式,
    • 当第一个参数为函数的时候,可以传递可选的第二个参数和其他任意参数,第二个参数之后的所有参数都会传递给第一个参数(这个函数)作为参数
    • 当第一个参数为字符串的时候,可以传递可选的第二个参数,其他再多的参数传递都会被忽略
  • 定时器可以嵌套,但嵌套超过5层的嵌套,调用间隔会设置为最少4ms chromium的实现
  • 定时器不能保证都能准确按时执行,可能影响因素是CPU负载,其他任务等等
  • 有对应的clearTimeout/clearInterval清除对应的定时器,参数为setTimout/setInterval的返回值

Promise

作为ESx规范的内容,用来获取延迟计算的最终的结果,可以通过链式调用的方式来定义流程。

Generator

ESx规范

async/await

ESx规范

异步I/O

HTML页面中的各种资源加载

XMLHTTPRequest(严格说也算I/O)

异步数据请求规范

postMessage

参考

Server Send Event

参考

FetchAPI

内置数据请求封装

SCRIPT异步脚本加载

脚本加载

脚本解析

其他常见事件

  • DOM事件监听
  • ResizeObserver
  • ReportingObserver
  • PerformanceObserver
  • IntersectionObserver
  • MutationObserver

NodeJS中的异步

回调函数

setTimeout/setInterval

Promise

Generator

async/await

作用和用法跟浏览器类似,都是对ECMAScript的实现,只是明确下这是两种实现,对于非标准的内容可能有差异,比如console ,即使标准的内容也可能有差异,因为是两种实现,有标准不一定都会遵守,这个就涉及到商业化和产品化以及发展历史的事,到现在浏览器对于HTML5还是有不一致的实现,Node对于ECMAScript还是不完全实现或者实现方式不一样等等。

综合说明

关于宏任务和微任务

宏任务归类

  • setTimeout
  • setInterval
  • I/O
  • 事件
  • postMessage
  • setImmediate
  • request.AnimationFrame
  • UI渲染

微任务归类

  • Promise.then
  • MutationObserver
  • process.nextTick

标准之外

在整个Web发展的过程中,浏览器占了很大的比重也起了很关键的作用,从各种各样的浏览器厂商的出现和消失,也可见一班。奇怪的是,虽然浏览器的发展现在越来越趋一化,就连独占鳌头的微软老大也都忍痛割爱易弦更张拥抱开源Chromium,但浏览器目前为止,是没有一个标准的,虽然浏览器实现了很多标准比如ECMAScript,HTML5,HTTP,DOM,XML等,浏览器本身是没有一个标准规范的。

在浏览器发展历程从早期的野蛮生长,到后来的相互学(抄)习(袭),到后来的百花齐放,导致前端开发者苦不堪言,就在十年前,我还在MathEdit项目中处理各种IE6、7、8,Safari,Opera,Firefox以及各种当时的国产浏览器的兼容性问题(那会Chrome貌似还是0.x.y的beta版本),当时处理的方式还是通过UA判断指定的浏览器,这种方法后来被浏览器特性检测取代,但又由于各种浏览器开发商作弊的行为(比如特性检测说是支持某些HTML5的特性,但实际调用的时候没有的这种情况),还是没有一个相对通用的解决办法。

综上,简单总结,浏览器对标准的实现本身具有滞后的特点,同时呢也有对标准实现有差异的地方,因为标准也是有大部分浏览器厂商参与制定的,标准也不一定能被标准实现,比如HTML5就有两个标准不是(分分合合,现在又合好了,参考)。而剩下的非标准的内容,比如宿主环境的API(比如console.log 到底是同步还是异步,不同浏览器可能不一样,浏览器和Node环境可能不一样),这个就看浏览器厂商的心情和排期了。浏览器兼容性问题对开发者来说已经是好太多了,但这个问题也会是一个常态化存在的问题,原因是上面说到的多方面的。

核心内容解析

浏览器中的实现

脚本加载

script 元素

默认为JavaScript,也就是type=text/javascript,除了每一个HTML元素都有的公共属性(当前22个)和公共事件(当前66个)外还有如下特有的属性值(动态修改属性值无效):

  • src 资源地址
  • type 脚本类型,早期还有其他的不同的type,比如vbscript,jscript等,现在几乎统一为JavaScript。
    • 省略或者设置为type/javascript表示使用传统的脚本加载方式,这种方式受asyncdefer配置的影响(在设置了src的情况下)。
    • 设置为module就按ES module的方式加载,此时不受defer属性的影响,但还是受async属性的影响(不管设置没设置src属性)。设置其他值,元素内容会被认为是数据块而不处理。
  • nomodule 在支持module的浏览环境中禁用module支持
  • async 脚本准备好就执行,不阻塞加载过程
  • defer 延迟执行,加载过程完成后统一执行
  • crossorigin 设定处理跨越的配置,可选值``
  • integrity 在子资源完整性检查中的完整的元数据
  • referrerpolicy 元素初始化的资源加载中的资源引用策略

脚本加载的异步

  • <script> 解析到script后暂停解析,直接加载对应资源,加载完成后执行,执行完成后再继续解析
  • <script defer> 解析到script后继续解析,并行加载对应资源,等页面解析完成后,执行对应的资源
  • <script async> 解析到script后继续解析,并行加载相应资源,加载完成后直接执行,执行完之后再继续解析
  • <script type=module> 解析到script后继续解析,并行加载对应资源,加载完成后并行加载资源所有对应的依赖,等页面解析完成后直接执行
  • <script type=module async> 解析到script后继续解析,并行加载对应资源,加载完成后并行加载资源所有对应的依赖,依赖加载完成后直接执行,执行完成后再继续解析

脚本动态加载

  • 动态修改script元素的属性无效
  • 如果使用document.write写入的话,会立即执行并阻塞页面解析,使用innerHTMLoutterHTML属性的话,脚本不执行
  • script元素API
    • text 属性获取或者设置当前元素的脚本内容
    • support 方法判断是否支持classicmodule的方法

事件循环

规范参考

事件循环是用来协调事件、用户交互、脚本、渲染、网络等等用户代理中的各个事务的机制。不同的地方可能有不同的事件循环。

每一个事件循环可以有一个或者多个事件队列,一个事件队列就是一组任务,虽然说事件队列,但事件队列的访问方式不完全按照队列的访问方式来运作,比如处理一个队列的时候是取第一个可执行的任务来执行,而不是取第一个任务(第一个可能不可执行)。微任务队列不是任务队列。

任务

任务分类
事件

分发一个事件对象到一个事件目标对象,通常由一个【绑定任务】(deticated task)来做,不是所有的事件都用任务队列来分发,也有许多在其他任务中分发。

解析

HTML解析器解析文本词法到并处理解析结果,也是一个典型的任务。

回调

调用回到函数通常也是一个【绑定任务】

资源调用

当一个场景获取了一个资源,如果获取过程是非阻塞的情况,那么接下来在这个资源部分或者全部可用情况下的处理过程,是用一个任务来完成得的

DOM操作变更

有些元素用对DOM操作进行响应的任务,比如在元素插入到文档的时候

任务源
  • DOM操作
  • 用户交互
  • 网络
  • 历史记录
任务的要素
  • 步骤 任务要做的事的先后内容
  • 任务源 用来分组和序列化相关的任务
  • 文档 任务对应的文档,如果在浏览器窗口的事件循环的话
  • 脚本执行 一组用来跟踪任务脚本执行过程的环境配置对象

每一个任务都有一个特定的事件源,每一个事件源都跟一个特定的任务队列绑定。

每个事件循环都有一个当前执行的任务,可以是一个任务或者是null ,初始化是null 也常被用来处理重入。

每个事件循环都有一个微任务队列,初始为空,微任务是一个口语化的对一个用微任务入队算法创建的任务的一个指代。

每个事件循环都有一个微任务检查点的标志位,初始化为false ,通常用来阻断对微任务检查点重入的调用。

每个window事件循环都有一个高精度的时间戳来记录上一次渲染的世界,初始为0。

每个window事件循环都有一个高精度的时间戳来记录上一次空闲周期的开始时间,初始为0。

任务入队(全局任务,元素任务,微任务都类似)
  • 如果没有指定事件循环,就用默认的(从当前调用上下文推断出来的)
  • 如果没有指定文档,就用默认的(从当前调用上下文推断出来的)
  • 创建一个新的任务对象,并设置steps,source,document 字段
  • 设置新任务的脚本执行环境配置对象为空
  • 把新任务追加到根当前任务源关联的任务队列上。

微任务也有可能被转移到常规任务队列中,如果初始化过程,它[spins the event loop] 就会被转移,这也是唯一的一起用事件源,事件文档,脚本执行环境配置对象集,执行微任务检查点的算法会忽略他们。

处理模型

事件循环比如从进入执行后一直持续的执行下面的步骤:

  • taskQueue为事件循环中一个有至少一个可执行任务的任务队列
  • oldestTasktaskQueue中第一个可执行的任务,并把这个任务从taskQueue中删除
  • 把事件循环当前正执行的任务设置为oldestTask
  • taskStartTime为当前的高精度时间
  • 执行oldestTask的步骤
  • 设置事件循环中当前正执行的任务为null
  • 微任务:执行微任务检查点
    • 如果事件循环的微任务检查点标识为true则返回
    • 设微任务检查点标识为true
    • 循环处理直到微任务队列为空
      • oldestMicrotask为事件循环的微任务队列的出队结果
      • 设事件循环的当前正执行任务位oldestMicrotask
      • 执行oldestMicrotask
      • 设事件循环当前正执行任务为null
    • 对每一个当前事件循环负责的环境设置对象,通知受拒绝的期约
    • 清理 IndexDB事务
    • 执行ClearKeptObjects()
    • 设置事件循环的微任务检查点标识为false
  • hasARenderingOpportunityfalse
  • now 为当前的高精度时间,也为任务执行结束时间
  • 执行如下步骤报告任务的执行时间
    • 设置顶级的浏览上下文为空集合
    • oldestTask的每一个环节设置对象,追加设置对象的顶级浏览上下文到顶级浏览上下文
    • 报告长时间的任务,传入taskStartTime,now,顶级浏览上下文,oldestTask
  • 更新渲染,如果当前事件循环是window事件循环的话,执行:
    • docs为所有的关联代理为当前事件循环的文档对象,除非遇上如下情况,否则可以任意排序:
      • 任意一个文档B的浏览上下文的容器文档为A,那么B需要再A的后面
      • 如果有两个文档A和B的浏览上下文都是另一个容器文档C的子浏览上下文,那么文档A和B在列表中的顺序需要与文档C的节点树的影子包含树顺序一致。
      • 下面这些文档的处理顺序都要按照这个列表中文档的顺序来
    • 渲染时机:从docs中删除浏览上下文没有渲染时机的文档
      • 是否有渲染时机:当用户代理可以展示浏览上下文的内容给用户,考虑到硬件刷新率的限制和性能优化的节流,也要考虑内容是否可展现及时是在视区外
      • 浏览上下文的渲染时机是基于像显示器的刷新率的硬件限制和其他想页面性能或者当前文档是否是visible的的状态共同决定的,渲染时机一般发生在有规律的时间间隔。
      • 本规范没有授权给任何一个选择渲染时机的模型,比如,如果一个浏览器想要达到60Hz的刷新率,那么渲染时机在1s中有60次;如果一个浏览器达没法维持这个刷新率, 它可以降低到30Hz的刷新,而不是偶尔丢帧。类似的,如果一个浏览上下文不可见,那么用户代理可以选择把刷新率降到4Hz或者更低。
    • 如果删完可不渲染的docs还是非空的话,那么设置hasARenderingOpportunitytrue,并且设置事件循环的上次渲染时机的为taskStartTime
    • 非必要渲染:从docs中删除如下条件的文档
      • 用户代理确认更新文档浏览上下文对视觉效果没影响,同时文档的动画动画帧回调为空
    • docs中删除用户代理确认因为各种原因要跳过的渲染
    • 对每一个全激活的文档,如果浏览上下文是顶级上下文的话,刷新autofocus
    • 对每一个全激活的文档,执行resize的步骤,传入now作为时间戳
    • 对每一个全激活的文档,执行scroll的步骤,传入now 作为时间戳
    • 对每一个全激活的文档,执行媒体查询和变更报告,传入now作为时间戳
    • 对每一个全激活的文档,更新动画和发送事件,传入now作为时间戳
    • 对每一个全激活的文档,执行全屏的步骤,传入now作为时间戳
    • 对每一个全激活的文档,如果用户代理检测到备用存储关联的CanvasRenderingContext2D或者OffscreenCanvasRenderingContext2D,上下文丢失,那么需要执行上下文丢失的步骤:
      • canvas为上下文的canvas属性,如果上下文是CanvasRenderingContext2D或者关联的OffscreenCanvas对象
      • 设上下文的上下文丢失属性为true
      • 重置渲染上下文到默认状态
      • shouldRestore为canvas上contextlost的事件触发结果,并设置cancelable属性为true
      • 如果shouldRestorefalse,结束步骤
      • 尝试通过上下文的属性及他们与上下文的关联创建一个备用存储,如果失败了结束步骤
      • 设置上下文的上下文丢失属性为false
      • 在canvas上触发一个叫contextrestored的事件
    • 对每一个全激活的文档,执行文档的动画帧回调,传入now作为时间戳
    • 对每一个全激活的文档,执行文档的更新交叉监视,传入now作为时间戳
    • 对每一个docs中的文档调用标记绘制时间算法
    • 对每一个全激活的文档,更新文档和上下文的渲染和用户界面来表达当前的状态
    • 如果下面这些条件都成立的话,那么执行后面的步骤
      • 条件
        • 当前是window event loop
        • 在当前全激活的文档事件循环的任务队列里面没有任务
        • 事件循环的微任务队列为空
        • hasARenderingOpportunityfalse
      • 步骤
        • computeDeadline为下面的步骤
          • deadline 为事件循环的上一次空闲期开始时间+50,(+50)是为了保证视觉暂停的下限
          • hasPendingRendersfalse
          • 对每一个当前事件循环的同循环窗口windowInSameLoop执行如下步骤
            • 如果windowInSameLoop的动画帧回调不为空,或者用户代理确认windowInSameLoop可能有没有处理的渲染更新,设置hasPendingRenderstrue
            • 设置timerCallbackEstimateswindowInSameLoop的激活计时器的值
            • 对每一个timerCallbackEstimatestimeouteDeadline,如果timeoutDeadlinedeadline小,把timeoutDeadline设置为deadline
          • 如果hasPendingRenderstrue
            • nextRenderDeadline为事件循环的上一次渲染时机的时间。刷新率跟硬件相关也跟实现相关,比如一个60Hz的刷新率,nextRenderDeadline为大约为上次渲染时机时间之后16.67ms
            • 如果nextRenderDeadlinedeadline少,就返回nextRenderDeadline
          • 返回deadline
        • 对当前事件循环的同循环窗口中的每一个窗口,用computeDeadline对窗口执行开始空闲时间的算法
    • 如果这是一个woker事件循环,然后:
      • 如果这个事件循环的代理的单个领域的全局对象是一个受支持的DedicatedWorkerGlobalScope,并且用户代理确认在这个时候更新它的渲染有用的话,就执行如下:
        • now为当前的高精度时间
        • DedicatedWorkerGlobalScope执行动画帧回调,传入now作为时间戳
        • 更新绑定的worker的渲染来表达当前的状态
      • 如果在事件循环的任务队列里面都没有任务并且WorkerGlobalScope对象的closing标识为true的话,就销毁当前事件循环,终止步骤,继续执行工作脚本的步骤

源码体验

chromium中源码在 src/base/message_loop/message_pump_default.cc 中,直接在网页上看可以简单点.

如果要下载下来的话也行,需要准备50G左右的磁盘空间,如果要编译的话占用空间会更多,同时有几个事要做,翻墙的梯子,给shell配梯子,给git配梯子,然后应该就可以一路通过了。

直接看Run方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void MessagePumpDefault::Run(Delegate* delegate) {
AutoReset<bool> auto_reset_keep_running(&keep_running_, true);

for (;;) {
#if defined(OS_APPLE)
mac::ScopedNSAutoreleasePool autorelease_pool;
#endif

Delegate::NextWorkInfo next_work_info = delegate->DoWork();
bool has_more_immediate_work = next_work_info.is_immediate();
if (!keep_running_)
break;

if (has_more_immediate_work)
continue;

has_more_immediate_work = delegate->DoIdleWork();
if (!keep_running_)
break;

if (has_more_immediate_work)
continue;

if (next_work_info.delayed_run_time.is_max()) {
event_.Wait();
} else {
event_.TimedWait(next_work_info.remaining_delay());
}
// Since event_ is auto-reset, we don't need to do anything special here
// other than service each delegate method.
}
}

从上面代码可以看出

  • 这是一个死循环(所谓的事件循环或者这里的消息循环),进到这里面就要一直执行,直到退出
  • 有能立即执行的任务就立即执行

从上面代码所在目录的文件可以看出

  • 针对不同平台和不同场景消息循环都有些差异化的实现

不同的任务类型可参考

更多内容可以参考chromium文档 像事件循环这样公共的内容代码都在src/base里面,比如任务相关的文档

NodeJS中的实现

NodeJS的依赖

NodeJS指南

定时执行

  • setTimeout(定时器) clearTimeout
  • setInterval(定时器) clearInterval
  • setImmediate clearImmediate
    • 在本轮事件循环的所有I/O操作之后执行
    • 在下一轮事件循环的所有定时器操作之前执行
  • process.nextTick
    • 在所有Immediate和所有I/O操作之前执行
    • 原子执行,过程不能中断

事件循环

  • NodeJS初始化事件循环,处理完输入脚本之后,剩下的就是事件循环来处理了
  • 每一个事件阶段都有一个先进先出回调队列,虽说是队列但不一定按队列的形式实现,比如定时器就是找到点的先执行,不一定是队首的元素
  • 当事件循环执行到每个阶段,会在允许情况下执行该阶段的所有操作,然后进入下一个阶段
    • 要么执行完所有在队列里面的回调
    • 要么执行完最大的回调个数限制的回调数
  • 事件循环中的每一个回调(操作)完成后,如果有process.nextTick设定的回调,要立即全部执行完(这个看起来是亲生的,相比setImmediate这个后娘养的来说),再进行下一个回调
  • 事件循环的每个阶段及其执行内容
    • timers
      • 执行setTimeoutsetInterval设定的回调;
      • 只能保证不会提前执行,有可能被其他任务拖延(还没轮到这个阶段,设定的时间已经过去)
    • pending callbacks
      • 执行除timerscheckclose callbacks之外的所有回调
    • idle, prepare
      • 内部使用的阶段
    • poll
      • timers执行也在这里控制
      • 新的I/O事件的处理,此处可能会阻塞
      • 队列非空,执行当前阶段到最大数量限制或者队列置空
      • 队列为空
        • 如果check不为空,执行check中的全部内容;
        • 如果check为空,等待回调添加,然后立即执行
        • 检查timers中到点的回调,并执行timers阶段
    • check
      • setImmediate设定的回调(相比起来后娘养的)
      • 在poll完成时执行
    • close callbacks
      • I/O关闭的回调,比如socket.on(‘close’,’…’)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

闲扯淡

NodeJS官方文档比较setImmediateprocess.nextTick

  • process.nextTick在当前阶段立即执行
  • setImmediate在下一轮事件循环的时候尽早执行

如果把tick理解为一次回调的话,就没有那一段说的那个问题了,immediate再快也要等到高优先级的搞完了才到,而tick可以理解为秒针动一次(一个回调) ,一次循环不能为一个tick

源码体验

NodeJS官方文档,可以看到NodeJS是使用libuv来实现事件循环的,对应的源码在src/deps/uv/src/unix/core.c 不同平台也有不同实现

同样的也是看run方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);

while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);

timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);

uv__io_poll(loop, timeout);

/* Run one final update on the provider_idle_time in case uv__io_poll
* returned because the timeout expired, but no events were received. This
* call will be ignored if the provider_entry_time was either never set (if
* the timeout == 0) or was already updated b/c an event was received.
*/
uv__metrics_update_idle_time(loop);

uv__run_check(loop);
uv__run_closing_handles(loop);

if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}

r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}

/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;

return r;
}

可以看到除了跟文档描述一致的处理结构之外,还有不同情况的特殊处理。

最后更新: 2022年03月02日 03:32

原始链接: http://rawbin-.github.io/async/2018-12-11-fe-async/

× 赞赏这个人~
打赏二维码